👉腾小云导读
作为一个天然跨平台的产品,腾讯会议从第一行代码开始,团队就坚持同源同构的思想,即同一套架构,同一套代码,服务所有场景。过去一年,腾讯会议,迭代优化了 20000 个功能,稳定支持了数亿用户,其客户端仅上层业务逻辑代码就超 100 万行,经过优化,目前在 Windows 平台上的编译时间最快缩短到 10秒,成为行业 C++ 跨平台项目的标杆。本文将详细介绍背后的优化逻辑,希望给业界同行提供参考。
👉看目录,点收藏1 编译加速有哪些方向?
2 如何优雅的预编译 Module 产物?
2.1 构建在哪执行
2.2 如何增量发布产物
2.3 预编译产物上传到何处
2.4 如何使用预编译产物
3 发布 Module 产物
4 使用Module产物
4.1 匹配产物
4.2 CMake产物替换源码编译
4.3 自动Generate
4.4 半自动Generate
4.5 IDE显示源码
5 断点调试
5.1 Android产物替换
5.2 成也Maven,败也Maven
5.3 Android Studio显示产物源码
6 万物皆可增量
7 构建参数
8 总结
大家知道,编译是将源代码经过编译器的预处理、编译、汇编等步骤,生成能够被计算机直接执行的机器码的过程,而这个过程是十分耗时的。常规的开发工具如 xcode、gradle 为了提高效率都会自带编译缓存的功能,即将上一次编译的结果缓存起来,对于没有修改的代码再次编译就直接使用缓存。但此这些缓存文件一般存在于本地,更新代码后难免需要一次重编,生成新的编译缓存。在会议这样一个上百人的团队里,修改提交十分频繁,更新一次代码所需要重编的代码量往往是十分巨大的。特别是一些被深度依赖的头文件被修改,往往等价于需要全量编译了。虽说也有一些工具能够支持云端共享编译缓存,如 gradle 的 remote build cache,但是对 C++ 部分并没有 cache,而且方案也不能跨平台通用。腾讯会议经历了框架 3.0 的模块化改造后,原本一整块代码按照业务拆分出了若干个小模块,开发需求修改代码逐渐集中在模块内部。这为我们的编译加速提供了新思路:每个业务模块之间是不存在依赖关系的,那么开发没有修改的模块是否可以免编译呢?- Module 独立运行,即不依赖其他 module 业务代码,任意一个 module 能够单独调试运行,这样就不需要编译其他 module 了;
- Module 预编译,将所有 module 预先编译好,本地开发直接下载预先编译的 module 产物,在编译完整app的时候直接组装即可,类似云端缓存的概念。
|
从长远来看,如果 Module 独立运行肯定是最优的,但是现阶段比较难实现,虽然会议的模块代码没有相互依赖,但业务功能间的相互依赖还是较高,模块要独立运行很难跑通完整功能;而 Module 预编译方案在会议项目中的可行性更高,不需要改动业务逻辑。那么既然要预编译所有的 module,就需要有一个机器自动构建 module 产物,并上传构建产物到云端。本地编译时从云端拉取预先编译好的产物来加速APP 编译。 2.1 构建在哪执行
首先,产物构建需要一台机器自动触发,很自然会想到持续集成(Continuous Integration,简称 CI)机器,这里我们选择了 Coding CI 来自动触发构建。首先来看看会议的开发模式:会议的开发模式是从主分支拉子分支开发新需求,开发完成后再合入主干。那么 CI 应该在哪条流水线构建 module 产物呢?需要为每条流水线都构建吗?如果在 master 流水线构建:那么开发分支一旦 module 代码有修改,module 缓存就失效了。只改动一两个 module 还好,但如果是 module 全部失效(比如 module 依赖的公共接口有变更),那增量构建的逻辑就形同虚设了。如果在 feature/bugfix 流水线构建:开发分支那么多,每个分支流水线都跑一次 module 构建,时间、仓库的存储成本都激增。而且,每个分支的构建产物相互独立,本地如果想要用产物加速编译,就必须得先启动流水线跑一次,等预编译产物构建完成了才可以使用。这对于拉一个 bugfix 分支修改两行代码就修复一个 bug 的场景来说是不可接受的。 2.1.1 有没有更加优雅的方式呢?
这里首先分析一下 module 之间的的关系,我们的module 相互之间代码是没有依赖的,module 共同依赖一些基础代码,我们称之为 module API。其实,大多数情况下,module 构建出来的产物,对于其他分支来说,只要module API 没有变更就是可以复用的。那怎么最大化的利用上这些产物呢?这取决于如何管理 module 产物的版本号,只要分支代码有可用的版本号就可以复用产物。比如,从 master 拉一个 bugfix 分支,只需要改几行代码,就可以直接用master 的版本号就行了。如果是 feature 分支,一开始也是从 master 继承module 的版本号,随着功能开发的进行,当我们修改了 module A 的代码,再手动更新一下 module A 的版本号,最后随着 feature 一起合入 master。这样一套流程似乎可行,只是实际操作起来会给开发者带来一定的使用成本,因为需要开发者手动管理 module 的版本号。试想一下,如果有两个 feature 分支都同时修改了同一个 module 的代码,那他们都会去更新这个 module 的版本号,MR 的时候就会发生版本冲突:这时就必须是 feature A merge feature B 的代码,然后重新更新 Module B的版本号再构建。可能一个 module 代码冲突还好,但如果是 module API 的修改了呢?那就是所有 module 都需要更新了,这是非常机械且令人头疼的!某著名大佬曾说过:“但凡重复的工作,我都希望交给机器来做”,这种明显重复的机械的工作,能否直接交给机器完成呢?通过分析不难发现,在构建参数一致的情况下,module 产物的版本号和我们的代码是一一对应的,即只要 module 代码有修改那我们就应该更新这个module 的版本号。那我们有没有什么已有的东西符合这个属性来当作版本号呢?有!Git commit ID 不就是吗?在我们的认知里,commit ID 似乎是反映整个项目所有代码的版本,但需要给每个 module 建立各自的版本记录,commit ID 能满足吗?熟悉 git 的人应该知道,git 可以通过指定<pathspec>参数来获取特定目录的提交记录。我们可以以此为突破口,获取每个 module 的 commit ID 作为 module 的版本号:这样,只需要输入 module 的代码目录,就可以推算出这个 module 代码对应的预编译产物版本号,那我们就无需自己来管理版本号了,交给 git 管理:- module 发布时,根据 module 目录得到 commit ID 作为版本号上传产物;
- 本地拉取产物时,根据同样的规则推算出 module 对应的版本号直接下载。
|
如此,就省掉了繁琐的版本号维护流程。也正因为 module 版本号是 commit ID,不同分支只要 module 的代码没有变,那 commit ID 也就不会变,不同分支间的 module 产物也就可以复用。因此,我们可以只要在 master 或者 master&feature 分支触发产物的构建,就能覆盖绝大多数分支。 2.2 如何增量发布产物
确定了使用 CI 来构建产物后,然后可以通过代码提交来自动触发 CI 启动。但为了避免浪费构建机资源,并不需要每次都构建发布所有模块,仅增量的发布修改过的模块即可。那如何判断模块是否修改过呢?与获取 module 版本号的方式类似,我们可以使用命令:git diff -- <pathspec>来找出本次构建有修改的模块。 2.3 预编译产物上传到何处
CI 构建出来的产物,需要一个仓库来保存,“善解人意”的腾讯软件镜像源为我们提供了各种仓库:Maven、Generic、CocoaPods。Android 有 Maven,上传、下载、管理版本十分方便,可惜其他平台并不支持。按照腾讯会议的一贯思路,就得考虑跨平台的方式来上传、下载产物。最终我们选择了原始的腾讯云 Generic 仓库。相较于其他镜像仓库,Generic 仓库具有以下优势:- 操作更自由,HTTP/HTTPS 协议上传、下载、删除
|
于是,我们自定义了一套产物打包、存储的规范,将各端构建好的产物,自己造轮子实现上传、下载、校验、解压安装等功能。正所谓:“造轮子一时爽,一直造轮子一直爽”。 2.4 如何使用预编译产物
为了让开发者无学习成本的使用预编译产物,产物的匹配和编译切换最好是无感的。即开发者并不需要主动配置,编译时脚本会自动匹配可用的预编译产物来构建 APP。以 module A 为例,自动匹配产物的大致流程如下:1.通过 git diff 查询当前的 changes list2.有修改到 module A 的代码,脚本就自动切换到 module A 的源码编译;3.没有修改 module A 的代码,则自动选择 module A 的产物构建。首先,发布产物需要确认的是预编译产物结构是怎样的。为了脚本逻辑能够跨平台,我们将每个模块输出的产物统一命名规范为:xx_module_output.zip,也就是各平台将自己每个 module 的产物打包到一个 zip 包中。但是 zip 包并不能反映产物的具体信息,比如对应的版本号、时间等,因此还需要一个 manifest 文件来汇总所有产物的相关信息。那么一个版本的代码对应的产物有:- xx_module_output.zip:xx_module的编译产物,总共会有n个xx_module_output.zip(n为模块个数);
- base_manifest.json:当前版本的所有 module 产物信息,包括 module 名字、版本号等。
而 base_manifest.json 里保存的信息结构如下:{
"modules": [
{
"name": "account", // 模块名称
"version": "7b2331b7e9", // 模块版本号,即模块commit ID
"time": "1632624109" // 模块版本时间,即模块commit时间戳
},
{
"name": "audio",
"version": "7b2331b7e9",
"time": "1632624109"
},
… ],
"appVersion": "7b2331b7e9", // 代码库git commit ID
"appVersionTime": "1632624109"// 代码库commit时间戳}然后,在有代码提交时,自动触发 CI 启动 Module 发布脚本,通过 git diff 找到提交的 change list,然后从 changes list 来判断哪些 module 有变更,对变更的 module 重编发布:流程看起来并不复杂,但实际操作上确有很多问题值得推敲。比如:我们知道git diff 是一个对比命令,既然是对比就会有一个基准 commit ID 和目标commit ID,目标 commit 就取当前最新的 commit 就好了。但基准 commit应该取哪个呢?是上一个提交吗?来看下面这个开发流程:开发者从 master 拉取了一个分支修复 bug,本地产生了两次 commit但没有 push,最后走 MR 流程合入主干。整个过程产生了三个 commit,如果直接使用最近一次的 commit 来 diff 产生结果,那么 diff 的 commit 是最后的那次 merge commit,结果正好是这次 bugfix 的所有改动记录。也就不用管前面的 commit a、commit b 了,这样看起来使用最近一次 commit diff 似乎没有问题。但如果这次编译被跳过或者失败了,那么下一次的 MR 还只关注本次 MR 的提交内容,中间跳过的代码提交就很可能一直没有对应构建产物了。因此,我们是通过查找最近一次有 base_manifest.json 文件与之对应的merge commit 来作为基准 commit。即从当前 commit 开始,回溯之前所有的merge commit,如果能够找到merge commit对应的base_manifest.json,那就说明这次 merge commit 是有发布 module 的,那么就以它为基准计算 diff。当然,我们并不会无限制的往前回溯,在尝试回溯了 n 次后仍然没有找到,则认为没有发布。其次,要如何 diff 特定 module 代码呢?前面提到 git diff 可以通过<pathspec>参数指定目录,根据这个特性,传入特定的 module 目录,就可以计算特定 module 的 change list 了:git diff targetCommitId baseCommitId -- path/to/module/xxx_module #获取module的diff(v1)
那么,只需包含这个 module 自身的代码目录路径就可以了吗?
答案是不够的。因为 module 还会依赖其他的接口代码,如 module API 的,接口的改动也会影响到 module 的编译结果,因此还需要包含 module API 的目录才行。于是获取 module diff 就变成下面这样:git diff targetCommitId baseCommitId -- path/to/module/xxx_module path/to/module-api #获取module的diff (v2)
另外,在 module 目录中,有些无关的文件并不影响编译结果(比如其他端的UI代码),在计算 diff 时我们需要将其排除,如何做到呢?也是通过<pathspec>路径,与之前不同的是需要在路径前面加”:!“。比如以下命令以Android 为例,我们需要将其他端的 UI 代码排除掉,那么获取某个 module 的 diff 命令最终就变成了这样:git diff targetCommitId baseCommitId --name-only -- path/to/module/xxx_module path/to/module-api :!path/to/module/xxx_module/ui/Windows :!path/to/module/xxx_module/ui/Mac :!path/to/module/xxx_module/ui/iOS #获取module的diff (final)
同样的,在发布 module 时,需要提供一个版本号,前面已经提到,可以使用module 的 commit ID 作为版本号。那么要如何获取 module 的 commit ID呢?git 命令都支持传入<pathspec>参数,那么通过 git log – <pathspec>设置 module 相关目录,即可得到这个 module 的 commit ID。不难发现,git log 命令的<pathspec>应该和 diff 是一致的,那么我们可以得出获取 module version 的命令:git diff targetCommitId baseCommitId --name-only -- path/to/module/xxx_module path/to/module-api :!path/to/module/xxx_module/ui/Windows :!path/to/module/xxx_module/ui/Mac :!path/to/module/xxx_module/ui/iOS #获取module的diff (final)
确定了 diff 与获取 module 版本号的算法,发布流程基本就可以走通了,接下来就是如何使用产物。首先需要确认的是,当前的代码要如何判断 CI 是否有发布与之匹配的 module产物。 4.1 匹配产物
前面我们提到在发布产物时,是通过回溯查找每个commit对应的base_manifest.json 来确定最近一次发布的 commit。那么匹配当前可用的产物也是类似的逻辑,通过回溯来找到最近有发布的 commit,整个 module 增量构建的流程如下:- 通过回溯 commit ID 找到最近一次发布的 base_manifest.json。
- 若最终没有找到这个 base_manifest.json,则证明当前版本没有 module 产物,所有 module 需要源码编译;
- 若能够找到此文件,文件中记录了预编译 module 的产物信息(版本、时间戳等)列表,如果能在产物列表中找到这个 module,那么就能够获取这个module 对应的产物;
- 得到 base_manifest.json 里的产物信息后,还需要使用产物的版本号 diff 判断出当前 module 是否有代码修改,确认无修改的情况则使用产物打包 App,有修改则使用 module 源码编译。
|
这里判断 module 是否修改的 diff 算法与发布产物时类似,以产物的版本号为base commit、设置 module 的<pathspec>目录执行 git diff 命令,来得到module 的 change list。产物匹配下载成功后,就是使用预编译产物来替换源码编译了。本着无使用成本的原则,我们希望替换过程能够脚本自动化完成,不需要开发者关心和介入就能无缝切换。 4.2 CMake产物替换源码编译
会议的跨平台层代码使用 C++ 实现,并采用 CMake 来组织工程结构的,所以C++ 代码的产物替换,需要从 CMake 文件入手。首先,C++ 预编译的产物是动态/静态库,这些对于 CMake 来讲就是library,可以通过 add_library() 或者 link_directories() 函数将其作为预编译库添加进来,以动态库 xx_plugins 为例,增量脚本会根据匹配的产物,会生成一个use_library_flag.cmake 文件,用来标记命中增量的库:# use_library_flag.cmake
set(lib_wemeet_plugins_bin "/Users/jay/Dev/Workspaces/wemeet/app/app/.productions/Android/libraries/wemeet_plugins")
set(lib_wemeet_sdk_bin "/Users/jay/Dev/Workspaces/wemeet/app/app/.productions/Android/libraries/wemeet_sdk")
...
set(lib_xxx patch/to/lib_xxx)
我们在使用 xx_plugins 的方式上做了改变:
- 命中增量时,通过 add_library 导入这个预编译的产物作为 library,lib_app link 预编译库;
- 未命中增量时,通过 add_subdirectory 添加 xx_plugins 的源码目录,lib_app link 源码库;
|
那么,增量产物命中后要实现产物/源码的切换,是不是只需要重新生成use_library_flag.cmake 这个文件就可以了呢?先来看看 CMake 的使用流程,主要分为 generate 和 build 这两个步骤:- generate - 根据 cmake 脚本中的配置确定需要编译的源码文件、链接库等,生成适用于不同构建系统(makefile、ninja、xcode 等)的工程文件、编译命令。
- build - 使用 generate 生成的编译命令执行编译
|
对于 Android 来说,cmake 是属于 gradle 管理的一个子编译系统,在构建Android 的时候 gradle 会执行 cmake generate 和 build。但对于 Xcode和 Visual Studio,cmake 修改之后是需要手动执行 generate的,原因是因为点击 IDE 的 build 按钮后仅仅是执行 build 命令,IDE 不会自动执行 cmake generate。那么,增量命中的产物列表有更新时,需要开发者手动执行 generate 一次才能更新工程结构,切换到我们预期的编译路径。但这样开发者就需要关心增量产物匹配情况是否有变更,增加了使用成本。 4.3 自动Generate
https://cliutils.gitlab.io/modern-cmake/chapters/basics/programs.htmlcmake 提供了运行其他程序的能力,既包含配置时,也包含构建时。对于Windows端我们可以插入一段脚本,在编译前做自动 generate 检测:if(WIN32)
# https://cliutils.gitlab.io/modern-cmake/chapters/basics/programs.html
add_custom_command(OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/running_command_at_build_time_generated.trickier"
COMMAND node ${CMAKE_CURRENT_SOURCE_DIR}/build_project/auto_generate_proj.js
${LIB_OS_TYPE}
${windows_output_bin_dir}
...
)
add_custom_target(auto_generate_project ALL
DEPENDS "${CMAKE_CURRENT_BINARY_DIR}/running_command_at_build_time_generated.trickier")
endif()
脚本会对比上一次构建的产物命中模块,当命中模块的列表有变更时,则启动子进程调用cmd窗口执行 Windows 的 generate:const cmd = `start cmd.exe /K call ${path.join(winGeneratePath, generateStr)}`;
child_process.exec(cmd, {cwd: winGeneratePath, stdio: 'inherit'});
process.exit(0);
注意: 使用 node 触发 cmd 执行 generate 脚本时,需要使用 detached 进程的方式,并使需要进程 sleep 足够时间以等待脚本执行结束。
4.4 半自动Generate
对于 iOS 和 OS X 平台,也可以 在 xcode 的 Pre-actions 环节插入一段脚本,来检测模块的命中列表是否有变更:但由于 xcode 本身检测到工程结构改变会自动停止编译,因此我们通过弹窗提示开发者,当检测到命中产物的模块已经更改时,需要手动 generate 更新工程结构。 4.5 IDE显示源码
产物/源码切换编译的问题解决了之后,我们也发现了了新的问题:在xx_plugins 命中增量产物时,发现 IDE 找不到 xx_plugins 的源码了!这是因为前面改造 CMakeLists.txt 脚本时,命中增量的情况下,并不会去执行 add_subdirectory(xx_plugins),那 IDE 自然不会索引 xx_plugins 的源码了,这显然是十分影响开发体验的。要解决这个问题就必须命中增量时也执行 add_subdirectory(xx_plugins) 添加源码目录,可添加了源码目录就会去编译它,那么可以让它不编译吗?答案是肯定的!我们来看看 cmake --build 的文档:Usage: cmake --build <dir> [options] [-- [native-options]]
Options:
<dir> = Project binary directory to be built.
--parallel [<jobs>], -j [<jobs>]
= Build in parallel using the given number of jobs.
If <jobs> is omitted the native build tool's
default number is used.
The CMAKE_BUILD_PARALLEL_LEVEL environment variable
specifies a default parallel level when this option
is not given.
--target <tgt>..., -t <tgt>...
= Build <tgt> instead of default targets.
--config <cfg> = For multi-configuration tools, choose <cfg>.
--clean-first = Build target 'clean' first, then build.
(To clean only, use --target 'clean'.)
--verbose, -v = Enable verbose output - if supported - including
the build commands to be executed.
-- = Pass remaining options to the native tool.
文档中提到 cmake build 命令是可以通过–target 参数来设置需要编译的target,而 target 则是通过 add_library 定义的代码库,默认情况下 cmake会 build 所有的 target。但如果我们指定了 target 后,那么 cmake 就只会编译该 target 及 target 依赖的库。在会议项目中 lib_app 依赖了其他所有的增量库,属于依赖关系中的顶层library,因此我们的 build 命令可以加上参数--target lib_app,那么:- 当 xx_plugins 未命中增量时,由于 lib_app 依赖了 xx_plugins 源码库,cmake 会同时编译 lib_app 与 xx_plugins;
- 当 xx_plugins 命中增量时,lib_app 依赖 xx_plugins 的是预编译库,cmake就只会编译 lib_app了。
|
因此,我们可以进一步改造CMakeLists.txt,让add_subdirectory(xx_plugins)始终执行:# CMakeLists.txt
include(use_library_flag.cmake)
...
# 引入wemeet_plugins源码目录
add_subdirectory(wemeet_plugins)
if(lib_wemeet_plugins_bin) # 命中增量
# 则导入该lib
add_library(prebuilt_wemeet_plugins SHARED IMPORTED)
set_target_properties(${prebuilt_wemeet_plugins}
PROPERTIES IMPORTED_LOCATION
${lib_wemeet_plugins_bin}/${LIB_PREFIX}wemeet_plugins.${DYNAMIC_POSFIX}) # DYNAMIC_POSFIX: so、dll、dylib
set(shared_wemeet_plugins prebuilt_wemeet_plugins) # 设置为预编译库
else() # 未命中增量
set(shared_wemeet_plugins wemeet_plugins) # 设置为源码库
endif()
...
# 引用wemeet_plugins
target_link_libraries(wemeet_app_sdk PRIVATE ${shared_wemeet_plugins})
这样在 xx_plugins 命中增量的情况下,开发者也可以继续用 IDE 愉快的阅读、修改源码了。
使用增量产物代替源码编译同时会带来的另一个问题:lldb 的断点调试失效了!要解决这个问题,首先要知道 lldb 二进制匹配源码断点的规则:lldb 断点匹配的是源码文件在机器上的绝对路径!(win 端没有用 lldb 调试器没有这个问题,只要 pdb 文件和二进制放在同级目录就能够自动匹配)那么,在机器 A 上编译的二进制产物 bin_A 由于源码文件路径和本地机器B上的不一样,在机器 B 上设置的断点,lldb 就无法在二进制 bin_A 中找到与之对应位置。那有办法可以解决这个问题吗?在 lldb 内容有限的文档我们发现这样一个命令:settings set target.source-map /buildbot/path /my/path
其作用就是将本地源码路径与二进制中的代码路径做一个映射,这样lldb就可以正确找到代码对应的断点位置了。那么“药”找到了,如何“服用”呢?
首先,我们会有多个库分别编译成二进制发布,并且由于是增量发布,各个库的构建机器的路径可能都不一样,因此需要为每个库都设置一组映射关系。好在source-map是可以支持设置多组映射的,因此我们的映射命令演变成了:settings set target.source-map /qci/workspace_A/path_to_lib1 /my_local/path_to_lib1
/qci/workspace_B/path_to_lib2 /my_local/path_to_lib2
/qci/workspace_C/path_to_lib3 /my_local/path_to_lib3
...
/qci/workspace_X/path_to_libn /my_local/path_to_libn
命令有了,何时执行呢?需要手动执行吗?依然还是无使用成本的原则,我们希望脚本能自动化完成这些繁琐的事情。了解 lldb 的开发者想必都知道“~/.lldbinit"这个配置文件,我们可以在执行增量脚本的时候,把 source-map 配置添加到“~/.lldbinit"中,这样 lldb 启动的时候就会自动加载,但是这里的配置是在用户目录下,会对所有 lldb 进程生效。为了避免对其他仓库/项目代码调试造成影响,我们应该缩小配置的作用范围,xcode 是支持项目级别的 .lldbinit 配置,也就是可以将配置放到 xcode 的项目根目录:# Mac端的.lldbinit放到Mac的xcode项目根目录:
app/Mac/Src/App/Application/WeMeetApp/.lldbinit
# iOS端的.lldbinit放到iOS的xcode项目根目录:
app/iOS/Src/App/Application/WeMeetApp/.lldbinit
Android Studio(简称 AS)就没有这么人性化了,并不能自动读取项目根目录的 .lldbinit 配置,但可以在 AS 中手动配置一下 LLDB Startup Commands:手动配置虽然造成了一定使用成本,但还好只需要配置一次。 5.1 Android产物替换
Android 中的子模块由于包含了 Java 代码和资源文件,预编译的产物就不是动态库/静态库了,产物替换得从 gradle 入手。前面文章有提到,为了更好的跨平台,我们选择了 Generic 仓库来存储增量构建的产物。 熟悉Android 的开发者都知道,Android 平台集成预编译产物的方式有两种:如果选择本地文件集成,那么我们就需要将模块源码打包成 aar 文件,但会遇到一个问题:若模块采用 maven 集成的方式依赖了三方库,是不会包含在最终打包的 aar 文件中的,这就会导致产物集成该模块时丢失了一部分代码。而Google 推荐的集成方式都是 maven 集成,因为 maven 产物中的 pom.xml文件会记录模块依赖的三方库,方便管理版本冲突以及重复引入等问题。那么如何在 Generic 仓库中使用 maven 集成呢?Generic 仓库其实就是一个只提供上传、下载的文件存储服务器,文件上传、下载均需自行实现,因此,每一个模块产物的文件内容各端可以自行定义,最终打包成一个压缩包上传到仓库即可。那么对于 Android 端我们可以将每个模块产物按照本地maven仓库的文件格式进行打包发布:app/.productions/Android/repo/com/tencent/wemeet/module/chat/│ ├── chat-f4d57a067d-prebuilt-info.json│ ├── chat-f4d57a067d.aar│ ├── chat-f4d57a067d.aar.md5│ ├── chat-f4d57a067d.aar.sha1│ ├── chat-f4d57a067d.aar.sha256│ ├── chat-f4d57a067d.pom│ ├── chat-f4d57a067d.pom.md5│ ├── chat-f4d57a067d.pom.sha1│ ├── chat-f4d57a067d.pom.sha256│ └── chat-f4d57a067d.pom.sha512├── maven-metadata.xml.md5├── maven-metadata.xml.sha1├── maven-metadata.xml.sha256└── maven-metadata.xml.sha512 |
以上就是模块 chat 以 maven 格式进行发布产物的文件列表,可以看到该仓库中只有一个版本(版本号:f4d57a067d)的产物,也就是说每个版本的增量产物其实就是一个 maven 仓库,我们将产物下载下来解压后,通过引入本地maven仓库的方式添加到项目中来:// build.gradle
repositories {
maven { url "file://path/to/local/maven" }
}
dependencies {
implementation 'com.tencent.wemeet.module:chat:f4d57a067d'
}
集成方式敲定了,那要如何自动切换呢?gradle 本身就是脚本,那么我们可以在增量脚本执行后,根据脚本的执行结果,命中产物的模块则以 maven 方式依赖,未命中的则以源码依赖。为了简化使用方法,我们定义了一个 projectWm函数:// common.gradle
gradle.ext.prebuilts = [:]
// setup prebuilt result
...
ext.projectWm = { String name ->
String projectName = name.substring(1) // remove ":"
if (gradle.ext.prebuilts.containsKey(projectName)) { // 命中增量产物
def prebuilt = gradle.ext.prebuilts[projectName]
return "com.tencent.wemeet.module:${projectName}:${prebuilt.version}"
} else { // 未命中增量产物
return project(name)
}
}
// build.gradle
apply from: 'common.gradle' // 引入通用配置
...
dependencies {
implementation projectWm(':chat')
...
}
projectWm 里面封装了替换源码编译的逻辑,这样我们只需要将所有的project(":xxx")改成projectWm(":xxx") 即可,使用方便简单。 5.2 成也Maven,败也Maven
虽然 maven 的依赖管理给我们带来了便利,但对于产物替换源码编译的场景,也带了新的问题。看这样一个 case,有 A、B、C 三个模块,他们的依赖关系如下:前面的 projectWm 方案,对于模块A这种单一模块可以很好的解决问题,但对于模块 B 依赖模块 C 这种复杂的依赖关系却不适用。比如模块 B 命中增量、模块 C 未命中时,由于 B 使用 projectWm 替换成了 maven 依赖,而模块 C 会因为模块的 maven 产物中 pom.mxl 定义的依赖关系给带过来,也就是模块 C也会是 maven 依赖,而无法变成源码依赖。我们必须将模块 B 附带的 maven依赖中的模块 C 再替换源码!通过查阅 gradle 文档,我们发现 gralde 提供了dependencySubstitution 功能可以将 maven 依赖替换成源码,用法也十分简单:于是我们可以将为命中的 module C 再替换成源码编译:configurations.all {
resolutionStrategy.dependencySubstitution.all { DependencySubstitution dependency ->
if (dependency.requested instanceof ModuleComponentSelector) {
def group = dependency.requested.group
def moduleName = dependency.requested.module
if (group == 'com.tencent.wemeet'|| (group.startsWith('application') && dependency.requested.version == 'unspecified')) {
def prebuilt = gradle.ext.prebuilts[moduleName]
if (prebuilt == null) { // module未命中
def targetProject = findProject(":${moduleName}")
if (targetProject != null) {
dependency.useTarget targetProject
}
}
}
}
}
}
替换好了本以为万事大吉了,但实际编译运行时,随着命中情况的变化,经常偶发的失败:Could not resolve module_xx:
究其原因,还是上面的替换没有起作用,替换的源码模块找不到,难道 gradle提供的API有问题?通过仔细阅读文档,发现这样一段话:
意思就是Dependency Substitution 只是帮你把依赖关系转变过来了,但实际上并不会将这个 project 添加到构建流程中。所以我们还得将源码编译的 project 手动添加到构建中:// build.gradle
dependencies { implementation project(":xxx") }那接下来的问题就是如何找到编译 app 最终需要源码编译的 module,然后添加到 app 的 dependencies{}依 赖中。这就要求得拿到所有 module 的依赖关系图,这个并不困难,在gradle configure之后就可以通过解析configurations 获取。但问题是我们必须得在 gradle configure 之前获取依赖关系,因为在 dependencies{} 中添加依赖是在 gradle configure 阶段生效的。问题进入了陷入死循环,这样一来,我们并不能通过 gradle的configure 结果获取依赖关系,得另辟蹊径。前面提到过,maven 会将模块产物依赖的子模块写到 pom.xml 文件中,虽然pom.xml 里记录的依赖并不全,但是我们可以将这些依赖关系拼凑起来:假设我们有上图的一个依赖关系,module A、B、C 都命中了增量,D、E 未命中,为了避免“Could not resolve module_xx”的编译错误,我们需要将module D、E 添加到 app 的 dependencies{} 中,那么脚本中如何确定呢:- app 在 configure 前可以读取 configurations 得倒 app 依赖了 module A、B;
- 由于 module B 命中了增量,因此可以通过 B 的 pom.xml 文件找到 B 依赖了C、D;
- 而 D 未命中增量,因此可以确定需要将 D 添加到 app 的的 dependencies{}中;
- 同理,我们可以通过 B → C 依赖链,拿到 C 的 pom.xml中记录的对E的依赖,从而确定E需添加到 app 的的 dependencies{} 中。
|
源码替换的流程,到这里大致就走通了,不过除此之外还有其他替换相关的细节问题(如版本号统一、本地 aar 文件的依赖替换等),这里就不继续展开讲了。 5.3 Android Studio显示产物源码
与 cmake 类似,命中产物的模块由于变成了 Maven 依赖,也会遇到 AS 无法正确索引源码的问题。首先,AS 中加载的源码是在 Gradle sync 阶段索引出来的,而我们用产物替换源码编译仅需要在 build 的时候生效。那是否可以在 sync 阶段让 AS 认为所有模块都未命中,去索引模块的源码,仅在真正 build才 做实际的替换呢?答案是肯定的,但问题是如何判断 AS 是在 sync 或 build 呢?gradle 并没有提供 API,但通过分析 gradle.startParameter 参数可以发现,AS sync 其实是启动了一个不带任何 task参数的gradle命令,并且将systemPropertiesArgs中的“idea.sync.active“参数设置为了 true,于是我们可以以此为依据来区分 gradle 是否在 sync:// settings.gradle
gradle.ext.isGradleSync = gradle.startParameter.systemPropertiesArgs['idea.sync.active'] == 'true'
ext.projectWm = { String name ->
String projectName = name.substring(1) // remove ":"
if (gradle.ext.isGradleSync) { // Gradle Sync
return project(name)
}
// 增量替换源码...
}
以上讲的是业务 module 的增量流程,会议的业务 module 之间是没有依赖关系的,结构比较清晰。那其他依赖关系更复杂的子工程呢?通过总结 module 的增量规律我们发现,一个子工程要实现增量化编译,需要解决的一个核心问题是判断这个是否需要重编。而 module 是通过 git diff – <pathspec>来判断,<pathspec>则是前面提到的 module 相关的代码路径:因此,这里可以延伸一下,即确定了子工程的源码及其依赖的接口路径后,都可以通过这套流程来发布、匹配增量产物,完成增量化的接入。前面有说到,当构建参数一致的情况下,产物版本和代码版本是一一对应的。但实际情况是,我们经常也需要修改构建参数,比如编译 release、debug 版本编译的结果往往会有很大差异。那么对于构建参数不一致的场景,增量构建的产物要如何匹配呢?这里引入 variant(变体)的概念,即编译的产物会因构建参数不同有多种组合,每一种参数组合构建出来的产物我们称之为其中一种变体。虽然参数组合可能有n种,但是常用的组合可能就只有几种。每一种组合都需要重新构建和存储对应产物,成本成倍增加,要覆盖所有的组合显然不太现实。既然常用的组合就那么几种,那么只覆盖这几种组合命中率就基本达标了。不同构建参数组合的产物之间是不通用的,所以存储路径上也应该是相互隔离的:
上图示例中,兼容了 package type(debug、release 等)和publish channel(app、private、oversea s等)两种参数组合,实际场景可能更多,我们可以根据需要进行定制。到这里,已经讲述了腾讯会议使用增量编译加速编译的大致原理,其核心思想就是尽量少编译、按需编译。在本地能够匹配到远端预先编译好的产物时,就取代本地的源码编译以节省时间。在全部命中增量产物的情况下,由于省去了大量的代码编译,全量编译效率也大幅提升:注:以上数据为2022年3月本地实测数据,实际耗时可能因机器配置不同而不一致。
截止到现在,会议代码全量编译耗时已超30min+,但由于采用模块化增量编译,在命中增量的情况下效果是稳定的,即编译耗时不会随着代码量的增长而持续增加。增量编译带来的效率提升是显著的,但现阶段也有一些不足之处:1.产物命中率优化:现阶段产物命中率还不够高,当修改了公共头文件时容易导致命中率下降,但这种修改可以进一步细分,如当新增接口时,其实并不影响依赖它的模块命中。2.自动获取依赖:目前工程依赖的关系是用配置文件人工维护的,因此会出现依赖关系更新滞后的情况。后续可以尝试从cmake、gradle等工具中获取依赖,自动更新配置。以上是本次分享全部内容,欢迎大家在评论区分享交流。如果觉得内容有用,欢迎转发~随着产品得到市场初步认可,业务开始规模化扩张。提速的压力从业务传导到研发团队,后者开始快速加人,期望效率能跟上业务的脚步,乃至带动业务发展。但不如人意的是,实际的研发效能反而可能与期望背道而驰——业务越发展,研发越跑不动。其实影响开发效率的因素远远不止代码编译耗时过长!
你在开发过程中会经常遇到哪些复杂耗时的头痛问题呢?
欢迎在评论区聊一聊你的问题。在4月13日前将你的评论记录截图,发送给腾讯云开发者公众号后台,可领取腾讯云「开发者春季限定红包封面」一个,数量有限先到先得😄。我们还将选取点赞量最高的1位朋友,送出腾讯QQ公仔1个。4月13日中午12点开奖。快邀请你的开发者朋友们一起来参与吧!